第27天 - 建立一個簡單的部落格頁面
在第27天,我完成了Vue 3 Vue 3 Composition API 課程,建立一個簡單的部落格來顯示文章。該網站呼叫了https://jsonplaceholder.typicode.com/posts 來取得所有文章並顯示使用者資訊。
部落格文章分為 5 個部分構建:
參考 https://tailwindcss.com/docs/installation/framework-guides 來安裝適用於 Vue 3、Svelte 5 和 Angular 的 TailwindCSS。
從 https://github.com/vueschool/vue-3-composition-api/tree/boilerplate/ 複製範本到你的 Vue 專案。作者以 JavaScript 撰寫程式碼,並且未使用 <script setup lang="ts"> 的語法糖c(syntatic sugar),因此我用 TypeScript 改寫了它。
建立一個 Post 類型來取得 id、title、body 和 userId。
export type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
}
建立一個 User 類型來取得 id 和 name。
export type User = {
  id: number;
  name: string;
}
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: Home,
    },
    {
      path: '/post/:id',
      name: 'Post',
      component: () => import('../views/Post.vue'),
    },
  ],
})
export default router
我還沒有學習 Vue Router,所以我使用 Vue 3 應用程式所產生的路由。
SvelteKit 使用file based routing。
routes/
   |-----posts/[id]
      |-----+layout.svelte
      |-----+page.svelte
      |-----+page.ts
   |-----+layout.svelte
   |-----+page.server.ts
   |-----+page.svelte
首頁是在 +page.svelte 中設計,+page.server.ts 定義了一個 load 函式來在伺服器端取得所有貼文。
在 posts/[id] 資料夾下,+page.svelte 是詳細頁面,+page.ts 用來透過 id 取得特定貼文。我無法讓 +page.server.ts 正常運作,所以改用了 +page.svelte。
import { Routes } from '@angular/router';
import { postResolver } from './post/resolvers/post.resolver';
export const routes: Routes = [
    {
        path: 'home',
        loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
    },
    {
        path: 'post/:id',
        loadComponent: () => import('./post/post.component'),
    },
    ... other routes ...
];
建立 home 與 post/:id 路由,並以延遲載入 (lazy loading) 方式載入 HomeComponent 和 PostComponent。
建立 home 和 post/:id 路由,並以延遲載入 (lazy loading) 方式加載 HomeComponent 和 PostComponent。
實作一個 fetchAll 函式,並宣告一個 posts ref 用來取得所有貼文。
import type { Post } from '@/types/post'
import { ref } from 'vue'
export function usePost() {
    const posts = ref<Post[]>([])
    
    const baseUrl = 'https://jsonplaceholder.typicode.com/posts'
    function fetchAll() {
        return fetch(baseUrl)
          .then((response) => response.json() as Promise<Post[]>)
          .then((data) => (posts.value = data))
          .catch((err) => alert(err))
      }
     
    return {
        posts,
        fetchAll,
    }
}
fetchAll 會向 baseUrl 發出請求以取得所有貼文,並將貼文賦值給 posts.value。
import type { Post } from '@/types/post'
import { ref } from 'vue'
export function usePost() {
    const post = ref<Post | null>(null)
    
    function fetchOne(id: number) {
        return fetch(`${baseUrl}/${id}`)
          .then((response) => response.json() as Promise<Post>)
          .then((data) => (post.value = data))
     }
    
    return {
        posts,
        post,
        fetchAll,
        fethcOne,    
    }
}
接著,宣告一個 post 的 ref 並實作一個 fetchOne 函式。fetchOne 函式接受一個貼文 ID 來取得該部落格貼文,並將結果賦值給 post.value。
然後,該 composable 回傳所有的 posts、post、fetchAll 和 fetchOne,以便元件可以存取。
export const BASE_URL = 'https://jsonplaceholder.typicode.com';
import type { PageServerLoad } from './$types';
import { BASE_URL } from '$lib/constants/posts.const';
import type { Post } from '$lib/types/post';
import type { RequestHandler } from '@sveltejs/kit';
// retreive all posts
export const load: PageServerLoad = async ({ fetch }: RequestHandler) => {
	const posts = await fetch(`${BASE_URL}/posts`)
		.then((response) => response.json() as Promise<Post[]>)
		.catch((error) => {
			console.error('Error fetching posts:', error);
			return [] as Post[];
		});
	return {
		posts
	};
};
load 函式使用原生的 fetch 函式來取得所有部落格貼文。
import { BASE_URL } from '$lib/constants/posts.const';
import type { Post } from '$lib/types/post';
import type { PageLoad } from './$types';
// retreive a post by an ID
export const load: PageLoad = async ({ params, fetch }): Promise<{ post: Post | undefined }> => {
	console.log('params', params);
    const post = await fetch(`${BASE_URL}/posts/${params.id}`)
		.then((response) => response.json() as Promise<Post>)
		.catch((error) => {
			console.error('Error fetching posts:', error);
			return undefined;
		});
	return {
		post
	};
};
這個 load 函式在瀏覽器端執行,並發出 HTTP 請求以透過 ID 取得貼文。
在 ApplicationConfig 中提供 provideHttpClient 供應者。
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(),
  ]
};
這是因為 httpResource 在底層使用 HttpClient,而我也需要它來進行 GET 請求以取得貼文。
provideRouter 是一個用來配置路由的供應者。withComponentInputBinding 是一個功能,能將路由資料、路徑參數和查詢參數轉換為輸入信號 (input signal),極大地簡化了路由資料傳遞至被路由元件的過程。
建立一個 PostsService。
import { httpResource } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Post } from '../types/post.type';
const BASE_URL = 'https://jsonplaceholder.typicode.com/posts';
@Injectable({
    providedIn: 'root'
})
export class PostsService {
    posts = httpResource<Post[]>(() => BASE_URL, {
        defaultValue: [] as Post[]
    });
}
我使用實驗性的 httpResource 來建立一個 posts 資源以取得所有貼文。
import { httpResource } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Post } from '../types/post.type';
const BASE_URL = 'https://jsonplaceholder.typicode.com/posts';
@Injectable({
    providedIn: 'root'
})
export class PostsService {
    readonly httpService = inject(HttpClient);
    getPost(id: number): Observable<Post> {
        return this.httpService.get<Post>(`${BASE_URL}/${id}`);
    }
}
我實作了一個 getPost 方法來取得單一貼文。此方法會在路由導航時用於路由解析器 (route resolver)。
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
import { of } from 'rxjs';
import { PostsService } from '../services/posts.service';
export const postResolver = (route: ActivatedRouteSnapshot) => {
    const postId = route.paramMap.get('id');
  
    if (!postId) {
      return of(undefined);
    }
  
    return inject(PostsService).getPost(+postId);
}
建立一個 postResolver,透過 ID 路徑參數來取得貼文。
更新 post/:id 路由,使其呼叫 postResolver。
{
        path: 'post/:id',
        loadComponent: () => import('./post/post.component'),
        resolve: {
            post: postResolver
        }
}
PostComponent 有一個 post 輸入信號 (input signal),其類型為 Post。
在 Home 元件中,匯入 usePost composable,並解構出 fetchAll 和 posts。
<script setup lang="ts">
import PostCard from '../components/PostCard.vue'
import { usePost } from '@/composables/usePost'
const { posts, fetchAll } = usePost()
fetchAll()
</script>
呼叫 fetchAll 函式以非同步方式取得所有貼文。然後,刪除 posts 陣列的模擬資料,以顯示真實資料。
<template>
  <div class="flex flex-wrap flex-grow">
    <PostCard v-for="post in items" :key="post.id" :post="post" />
  </div>
</template>
在 Post 元件中,匯入 usePost,並呼叫 fetchOne 來透過 ID 取得貼文。刪除 post 的模擬資料,以顯示真實資料。
import { useRoute } from 'vue-router'
import { usePost } from '@/composables/usePost'
const { post, fetchOne } = usePost()
const { params } = useRoute();
fetchOne(+params.id)
在 routes/+page.svelte 中,會執行 load 函式,頁面資料可以透過 $props() 巨集取得。
從 data 解構出 posts 以取得所有貼文。
<script lang="ts">
	import type { PageProps } from './$types';
	import PostCard from '$lib/components/post-card.svelte';
	const { data }: PageProps = $props();
	const { posts } = data;
</script>
<div class="flex flex-grow flex-wrap">
	{#each posts as post (post.id)}
		<PostCard {post} />
	{/each}
</div>
範本迭代 posts,並將每個貼文傳遞給 PostCard 元件。
<script lang="ts">
	import type { Post } from '$lib/types/post';
	import { resolve } from '$app/paths';
	type Props = {
		post: Post;
	};
	const { post }: Props = $props();
	const postUrl = resolve('/posts/[id]', { id: `${post.id}` });
</script>
PostCard 元件使用 resolve 函式來決定導向的 URL。/posts/[id] 是路由路徑,id 是路徑參數。
<div>
    <img src="https://placehold.co/150" alt="placeholder" style="background: #cccccc" width="150" height="150" />
    <a href={postUrl}>
		{post.title}
	</a>
</div>
<script lang="ts">
    import type { PageProps } from './$types';
    const { data }: PageProps = $props();
    const { post } = data;
</script>
在 posts/[id]/+page.svelte 中,load 函式會透過 ID 取得貼文。範本動態顯示貼文標題和內容,而使用者名稱目前是硬編碼的。
<div class="mb-10">
    <h1 class="text-3xl">{ post.title }</h1>
    <div class="text-gray-500 mb-10">by Connie</div>
    <div class="mb-10">{ post.body }</div>
</div>
範本動態顯示貼文標題和內容,而使用者名稱目前是硬編碼的。
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
import { Post } from './types/post.type';
import { User } from './types/user.type';
@Component({
  selector: 'app-post',
  styles: `
    @reference "../../styles.css";
    :host {
      @apply flex m-2  gap-2 items-center w-1/4 shadow-md flex-grow rounded overflow-hidden
    }
  `,
  template: `...inline template...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class PostComponent {
  post = input<Post>();
  user = signal<User>({
    id: 1,
    name: 'Connie',
  });
}
貼文會在路由導航期間被解析,因此它會作為輸入信號 (input signal) 提供給 PostComponent。使用者信號目前是硬編碼的值,但將會發出請求以取得貼文的使用者。
@let myUser = user();
@let myPost = post();
@if (myPost && myUser) {
  <div class="mb-10">
    <h1 class="text-3xl">{{ myPost.title }}</h1>
    <div class="text-gray-500 mb-10">by {{ myUser.name }}</div>
    <div class="mb-10">{{ myPost.body }}</div>
  </div>
}
@let 語法允許暫時指定信號的取得函式。當 myUser 和 myPost 被定義後,會顯示貼文標題、貼文內容和使用者名稱。
我們已成功顯示首頁、詳細頁面,並且設定好這個簡易部落格的路由。